Skip to content

feat(ui): announce blocking actions in the header and add a timing page with Buckaroo's spans - #318

Merged
paddymul merged 9 commits into
mainfrom
feat/blocking-action-strip
Oct 21, 2026
Merged

paddymul merged 9 commits into
mainfrom
feat/blocking-action-strip

Conversation

@paddymul

@paddymul paddymul commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Problem

When a page waits, nothing says what it is waiting on. On 2026-10-08 loading cars took 3.27 s: the companion's POST to Buckaroo sat for 3.2 s behind a 4.5 s summary-stats run for camera_violations (Buckaroo runs everything on one IOLoop thread), then Buckaroo's own handler took 39 ms. The activity log has the 3.27 s, and telemetry.jsonl has both the stats run and the 39 ms, but nothing puts them side by side.

Fix

  • tallyman_core/activity.py: a process-wide tracker. track(name, detail) tells listeners the action is about to start (action_start) and again when it ends (action_end, with the duration); an exception is recorded as the action's error and re-raised. The companion forwards both over the existing SSE channel (from worker threads, through the loop).
  • Announced: prepare entry and the buckaroo load_expr POST in BuckarooManager.load_session, and the diff data, promote diff, save code, reset, staleness and recalc routes.
  • Header: now: <action> <detail> · <elapsed> while one runs (the longest, +N more for the rest), last: <action> <detail> · <time> after, linking to the timing page. SSE action events do not bump version, so they do not refetch the catalog.
  • GET /{project}/api/timing?window_s=: the companion's actions and the spans Buckaroo posted back to telemetry.jsonl, on one clock (epoch ms).
  • Timing page: both on a shared axis, split into bursts (rows within 2 s of the latest end) so each burst is readable. A load_expr POST says how long after it started Buckaroo began handling it. That gap is queueing plus the request's transfer and parsing; the page does not separate them.

Not in this PR: Buckaroo announcing the start of its own work (it posts a span only when it ends, so the header cannot say a stats run is about to start; that needs a change in the buckaroo repo), and actions that run in the MCP process. The log is in memory, so a restart empties it.

Checked in a browser against a running companion: the pill showed now: buckaroo load_expr 71d4f4fc7932 · 3 ms and then last: … · 543 ms, and the timing page showed the companion's POST, Buckaroo's load_expr, ws_first_payload and stats.complete for it.

🤖 Generated with Claude Code

paddymul and others added 8 commits October 8, 2026 11:06
…iming page

The companion announces each blocking action before it runs and again when it ends, the header shows the running action and the last one, and GET /api/timing returns the companion's actions beside the spans Buckaroo posted back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eue and for trace labels

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…a timing page

activity.ts holds the pure state: a reducer for action_start and action_end, the now and last labels, the wait a load_expr POST spent in Buckaroo's queue, and the timeline layout. SSEContext keeps it without bumping version; ActivityStrip shows it in the header; TimingPage lays the companion's actions and Buckaroo's spans on one axis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…burst of activity

The rows lacked the flex layout the shared waterfall classes give, and one axis for the whole window made a half-second action a sliver next to another half a minute later. Rows within 2 s of the latest end share an axis. The note on a slow load_expr now says when Buckaroo began handling it, since the gap also holds the request's transfer and parsing, not only queueing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…em over SSE, and serve /api/timing

tallyman_core/activity.py tracks running and finished actions and tells listeners at each start and end. load_session announces preparing the entry and the load_expr POST; the diff, promote, save-code, reset, staleness and recalc routes announce themselves. The companion forwards the events over SSE and GET /{project}/api/timing returns the actions beside the spans Buckaroo posted back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…trip and timing page

Python: a malformed telemetry span 500s /api/timing; reading spans parses the whole file; refused requests (read-only,
unknown project) are announced as failed actions; a forced reload, the klass reload and the diff view's load_expr are
not announced; the async routes hold the event loop through their blocking work, so action_start is held until the
work is done; the hello event carries no activity snapshot.

Vitest: formatDuration prints the wrong unit at its edges; a zero-length row sticks out of its track; seeding the
header from a snapshot, the project filter, span keys and alias labels do not exist yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d fix the timing page's labels

The header's state is replaced by the activity snapshot on each SSE hello (first connect and every reconnect), so an
action_end missed while disconnected no longer leaves a 'now:' running for good, and /api/timing is no longer read just
to seed it. Activity is reset when the project changes and live events are filtered to the open project. Activity moves
into its own context so an action event re-renders the strip and the timing page, not every useSSE() consumer.

formatDuration picks its unit from the rounded value. layoutTimeline keeps a minimum-width row at the end inside the
track, and the grid-load waterfall uses it instead of its own copy of the math. Timing rows are keyed by what the span
is rather than its index, and aliases are labelled with their version and refetched when entries change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…pshot on hello, and read telemetry cheaply

The async routes ran their blocking steps on the event loop, so the action_start for a slow promote or save was held
until the work was done. promote_diff and put_code now run diff_keys, cached_result_expr and build_and_persist on the
thread pool, and reset, recalc, auto-recalc and /internal/notify do the same for reload_project_sessions.

The SSE hello event carries the project's running and recent actions, which the SPA replaces its header state with.
A request for an unknown project, or a write in serve mode, is refused before it is announced. A forced reload, the
klass reload and the diff view's load_expr are announced like the other POSTs that queue behind Buckaroo's stats.

/api/timing skips span records whose times are not finite numbers instead of failing, and reads telemetry.jsonl
backward only as far as its window instead of parsing the whole file on every poll.

The test that added a listener to the process-wide tracker now removes it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@paddymul

Copy link
Copy Markdown
Contributor Author

Review of #318 and how each finding was addressed

The review found 13 issues. The failing tests are in 49d16bc. The frontend fixes are in c85b787 and the backend fixes in 7da3036. The test commit was seen failing on CI in two runs: vitest first (14 failures), then pytest once the frontend commit let that step run (12 failures). The last run is green.

Only the /api/timing 500 was reproduced during the review. The rest came from reading the code and were confirmed by the new tests.

# Finding Fix
1 api_promote_diff and put_code ran blocking work on the event loop, so action_start was held until the work finished 7da3036: diff_keys, cached_result_expr and build_and_persist run on the thread pool. reload_project_sessions also ran inline in reset, recalc, auto-recalc and /internal/notify, and now runs on the pool too.
2 An action_end missed while the EventSource was down left a stale "now:" for good c85b787, 7da3036: the SSE hello carries the project's running and recent actions, and the SPA replaces its header state with them on every connect.
3 A span record with a null, non-numeric or missing time made /api/timing return 500 permanently 7da3036: records whose times are not finite numbers are skipped.
4 Activity was not scoped to a project c85b787: activity resets when the project changes and live events are filtered to the open project. 7da3036: hello and /api/timing use the same project filter. The header still does not show another project's actions, although all projects share one Buckaroo. That is a design choice for you.
5 Every /api/timing call, including the header seed, parsed all of telemetry.jsonl c85b787: the seed no longer calls /api/timing. 7da3036: read_spans(received_since_ms=...) reads the file backward and stops a minute past the window. It relies on lines being appended in receive order.
6 Other POSTs that queue behind Buckaroo were not announced 7da3036: the forced reload and the diff view's load_expr are announced as buckaroo load_expr, and the klass reload sweep as one buckaroo reload_expr action.
7 Expected HTTPExceptions showed as failed actions in every tab 7da3036: an unknown project and a write in serve mode are refused before anything is announced. A 4xx or 5xx raised after the work starts is still recorded as a failure, and so is a cancelled request.
8 A test left a listener on the process-wide tracker 7da3036: it removes the listener in a finally.
9 No test for SSE forwarding or the async wrappers 49d16bc: a live-uvicorn test that hello carries the snapshot, a live test that action_start arrives before a slow save finishes, and a heartbeat test on the event loop for save, promote, reset and notify. vitest covers the reducers. There is no React render test, since the repo has no testing-library. The header and timing page were checked in headless Chromium instead.
10 layoutTimeline duplicated the waterfall math in LogPage c85b787: LogPage calls it, and a zero-length row at the end of a burst stays inside the track.
11 Every action event re-rendered every useSSE() consumer c85b787: activity has its own context (useActivity).
12 Timing row keys used the array index, and alias labels ignored the version c85b787: keys come from spanKey (session, name, start), and aliases are labelled cars v2 when an alias has several versions. They refetch when entries change. A burst is still keyed by its first row.
13 formatDuration printed the wrong unit at its edges c85b787: the unit is chosen from the rounded value.

Not covered by a dedicated test: the reload_project_sessions call sites in recalc and auto-recalc. They use the same pattern as reset and notify, which are tested.

… of a wrapping decorator

The decorator had to return an async wrapper for async routes and a sync one for sync routes, and copy the signature with functools.wraps. A yield dependency does the same job without wrapping the route: FastAPI runs it on a worker thread, and the route body runs between the yield and the end of the with. One difference: a request whose body fails validation (422) is now announced and recorded as an error, because FastAPI validates the body after it has entered the dependency.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@paddymul
paddymul merged commit 4f0affb into main Oct 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant